Desbloqueie código robusto e type-safe em JavaScript e TypeScript com type guards de pattern matching, discriminated unions e verificação de exaustividade. Evite erros de tempo de execução.
Type Guard com Pattern Matching em JavaScript: Um Guia para Pattern Matching Type-Safe
No mundo do desenvolvimento de software moderno, gerenciar estruturas de dados complexas é um desafio diário. Seja lidando com respostas de API, gerenciando o estado da aplicação ou processando eventos de usuário, você frequentemente lida com dados que podem assumir uma de várias formas distintas. A abordagem tradicional usando instruções if-else aninhadas ou casos básicos de switch é muitas vezes verbosa, propensa a erros e um terreno fértil para erros em tempo de execução. E se o compilador pudesse ser sua rede de segurança, garantindo que você tratou todos os cenários possíveis?
É aqui que entra o poder do pattern matching type-safe. Ao pegar emprestado conceitos de linguagens de programação funcional como F#, OCaml e Rust, e aproveitar o poderoso sistema de tipos do TypeScript, podemos escrever um código que não é apenas mais expressivo e legível, mas também fundamentalmente mais seguro. Este artigo é um mergulho profundo em como você pode alcançar um pattern matching robusto e type-safe em seus projetos JavaScript e TypeScript, eliminando toda uma classe de bugs antes mesmo que seu código seja executado.
O que Exatamente é Pattern Matching?
Em sua essência, o pattern matching é um mecanismo para verificar um valor em relação a uma série de padrões. É como uma instrução switch superpotente. Em vez de apenas verificar a igualdade com valores simples (como strings ou números), o pattern matching permite que você verifique a estrutura ou forma dos seus dados.
Imagine que você está separando correspondência física. Você não verifica apenas se o envelope é para "João da Silva". Você pode separar com base em diferentes padrões:
- É um envelope pequeno e retangular com um selo? Provavelmente é uma carta.
- É um envelope grande e acolchoado? Provavelmente é um pacote.
- Tem uma janela de plástico transparente? É quase certamente uma conta ou correspondência oficial.
O pattern matching no código faz a mesma coisa. Ele permite que você escreva uma lógica que diz: "Se meus dados se parecem com isto, faça aquilo. Se tiverem esta forma, faça outra coisa." Este estilo declarativo torna sua intenção muito mais clara do que uma teia complexa de verificações imperativas.
O Problema Clássico: A Instrução `switch` Insegura
Vamos começar com um cenário comum em JavaScript. Estamos construindo uma aplicação gráfica e precisamos calcular a área de diferentes formas. Cada forma é um objeto com uma propriedade `kind` para nos dizer o que é.
// Our shape objects
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// PROBLEM: Nothing stops us from accessing shape.sideLength here
// and getting `undefined`. This would result in NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Este código JavaScript puro funciona, mas é frágil. Ele sofre de dois problemas principais:
- Sem Segurança de Tipos (Type Safety): Dentro do caso `'circle'`, o ambiente de execução do JavaScript não tem ideia de que o objeto
shapegarantidamente tem uma propriedaderadiuse não umasideLength. Um simples erro de digitação comoshape.raduisou uma suposição incorreta como acessarshape.widthresultaria emundefinede levaria a erros em tempo de execução (comoNaNouTypeError). - Sem Verificação de Exaustividade (Exhaustiveness Checking): O que acontece se um novo desenvolvedor adicionar uma forma
Triangle? Se ele esquecer de atualizar a funçãogetArea, ela simplesmente retornaráundefinedpara triângulos, e este bug pode passar despercebido até causar problemas em uma parte completamente diferente da aplicação. Esta é uma falha silenciosa, o tipo mais perigoso de bug.
Solução Parte 1: A Base com Discriminated Unions do TypeScript
Para resolver esses problemas, primeiro precisamos de uma maneira de descrever nossos "dados que podem ser uma de várias coisas" para o sistema de tipos. As Discriminated Unions do TypeScript (também conhecidas como tagged unions ou algebraic data types) são a ferramenta perfeita para isso.
Uma discriminated union tem três componentes:
- Um conjunto de interfaces ou tipos distintos que representam cada variante possível.
- Uma propriedade literal comum (o discriminante) que está presente em todas as variantes, como
kind: 'circle'. - Um tipo de união (union type) que combina todas as variantes possíveis.
Construindo uma Discriminated Union `Shape`
Vamos modelar nossas formas usando este padrão:
// 1. Define the interfaces for each variant
interface Circle {
kind: 'circle'; // The discriminant
radius: number;
}
interface Square {
kind: 'square'; // The discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // The discriminant
width: number;
height: number;
}
// 2. Create the union type
type Shape = Circle | Square | Rectangle;
Com este tipo Shape, dissemos ao TypeScript que uma variável do tipo Shape deve ser um Circle, um Square ou um Rectangle. Não pode ser mais nada. Essa estrutura é a base do pattern matching type-safe.
Solução Parte 2: Type Guards e Exaustividade Guiada pelo Compilador
Agora que temos nossa discriminated union, a análise de fluxo de controle do TypeScript pode fazer sua mágica. Quando usamos uma instrução switch na propriedade discriminante (kind), o TypeScript é inteligente o suficiente para refinar o tipo dentro de cada bloco case. Isso atua como um poderoso e automático type guard.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows `shape` is a `Circle` here!
// Accessing shape.sideLength would be a compile-time error.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows `shape` is a `Square` here!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows `shape` is a `Rectangle` here!
return shape.width * shape.height;
}
}
Note a melhoria imediata: dentro do case 'circle', o tipo de shape é refinado de Shape para Circle. Se você tentar acessar shape.sideLength, seu editor de código e o compilador TypeScript irão sinalizá-lo imediatamente como um erro. Você eliminou toda a categoria de erros em tempo de execução causados pelo acesso a propriedades incorretas!
Alcançando Segurança Real com Verificação de Exaustividade
Resolvemos o problema de segurança de tipos, mas e a falha silenciosa quando adicionamos uma nova forma? É aqui que impomos a verificação de exaustividade. Dizemos ao compilador: "Você deve garantir que eu tratei cada uma das variantes possíveis do tipo Shape."
Podemos conseguir isso com um truque inteligente usando o tipo never. O tipo never representa um valor que nunca deveria ocorrer. Adicionamos um caso default à nossa instrução switch que tenta atribuir a shape a uma variável do tipo never.
Vamos criar uma pequena função auxiliar para isso:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
Agora, vamos atualizar nossa função getArea:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// If we've handled all cases, `shape` will be of type `never` here.
// If not, it will be the unhandled type, causing a compile-time error.
return assertNever(shape);
}
}
Neste ponto, o código compila perfeitamente. Mas agora, vamos ver o que acontece quando introduzimos uma nova forma Triangle:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Add the new shape to the union
type Shape = Circle | Square | Rectangle | Triangle;
Instantaneamente, nossa função getArea mostrará um erro em tempo de compilação no caso default:
Argumento do tipo 'Triangle' não é atribuível ao parâmetro do tipo 'never'.
Isso é revolucionário! O compilador agora está agindo como nossa rede de segurança. Ele está nos forçando a atualizar a função getArea para lidar com o caso Triangle. O bug silencioso em tempo de execução tornou-se um erro de compilação claro e evidente. Ao corrigir o erro, garantimos que nossa lógica está completa.
function getArea(shape: Shape): number { // Now with the fix
switch (shape.kind) {
// ... other cases
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Add the new case
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Assim que adicionamos o case 'triangle', o caso default se torna inalcançável para qualquer Shape válido, o tipo de shape nesse ponto se torna never, o erro desaparece e nosso código está novamente completo e correto.
Indo Além do `switch`: Pattern Matching Declarativo com Bibliotecas
Embora a instrução switch com verificação de exaustividade seja incrivelmente poderosa, sua sintaxe ainda pode parecer um pouco verbosa. O mundo da programação funcional há muito favorece uma abordagem mais declarativa e baseada em expressões para o pattern matching. Felizmente, o ecossistema JavaScript oferece excelentes bibliotecas que trazem essa sintaxe elegante para o TypeScript, com total segurança de tipos e exaustividade.
Uma das bibliotecas mais populares e poderosas para isso é a `ts-pattern`.
Refatorando com `ts-pattern`
Vamos ver como nossa função getArea fica quando reescrita com ts-pattern:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Ensures all cases are handled, just like our `never` check!
}
Esta abordagem oferece várias vantagens:
- Declarativo e Expressivo: O código se lê como uma série de regras, declarando claramente "quando a entrada corresponder a este padrão, execute esta função."
- Callbacks Type-Safe: Observe que em
.with({ kind: 'circle' }, (c) => ...), o tipo decé inferido automática e corretamente comoCircle. Você obtém total segurança de tipos e autocompletar dentro do callback. - Exaustividade Embutida: O método
.exhaustive()serve ao mesmo propósito que nosso auxiliarassertNever. Se você adicionar uma nova variante à uniãoShape, mas esquecer de adicionar uma cláusula.with()para ela, ats-patternproduzirá um erro em tempo de compilação. - É uma Expressão: O bloco
matchinteiro é uma expressão que retorna um valor, permitindo que você o use diretamente em declaraçõesreturnou atribuições de variáveis, o que pode tornar o código mais limpo.
Capacidades Avançadas da `ts-pattern`
A ts-pattern vai muito além da simples correspondência de discriminantes. Ela permite padrões incrivelmente poderosos e complexos.
- Correspondência com Predicado com
.when(): Você pode corresponder com base em uma condição. - Correspondência com Curingas com
P.anyeP.stringetc: Corresponda à forma de um objeto sem um discriminante. - Caso Padrão com
.otherwise(): Fornece uma maneira limpa de lidar com quaisquer casos não correspondidos explicitamente, como uma alternativa ao.exhaustive().
// Handle large squares differently
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Becomes:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* special logic for large squares */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Match any object that has a numeric `radius` property
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Casos de Uso Práticos para um Público Global
Este padrão não é apenas para formas geométricas. É incrivelmente útil em muitos cenários de programação do mundo real que desenvolvedores em todo o mundo enfrentam diariamente.
1. Lidando com Estados de Requisição de API
Uma tarefa comum é buscar dados de uma API. O estado dessa requisição pode ser tipicamente uma de várias possibilidades: inicial, carregando, sucesso ou erro. Uma discriminated union é perfeita para modelar isso.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// In your UI component (e.g., React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Welcome! Click a button to load your profile.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Com este padrão, é impossível renderizar acidentalmente um perfil de usuário quando o estado ainda está carregando, ou tentar acessar state.data quando o status é error. O compilador garante a consistência lógica da sua UI.
2. Gerenciamento de Estado (ex: Redux, Zustand)
No gerenciamento de estado, você despacha ações para atualizar o estado da aplicação. Essas ações são um caso de uso clássico para discriminated unions.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` is correctly typed here!
// ... logic to add item
return { ...state, /* updated items */ };
case 'REMOVE_ITEM':
// ... logic to remove item
return { ...state, /* updated items */ };
// ... and so on
default:
return assertNever(action);
}
}
Quando um novo tipo de ação é adicionado à união CartAction, o cartReducer falhará na compilação até que a nova ação seja tratada, impedindo que você se esqueça de implementar sua lógica.
3. Processando Eventos
Seja lidando com eventos WebSocket de um servidor ou eventos de interação do usuário em uma aplicação complexa, o pattern matching fornece uma maneira limpa e escalável de rotear eventos para os manipuladores corretos.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`User ${e.userId} logged in.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Unhandled event: ${e.event}`));
}
Os Benefícios Resumidos
- Segurança de Tipos à Prova de Balas: Você elimina toda uma classe de erros em tempo de execução relacionados a formas de dados incorretas (ex:
Cannot read properties of undefined). - Clareza e Legibilidade: A natureza declarativa do pattern matching torna a intenção do programador óbvia, levando a um código mais fácil de ler e entender.
- Completude Garantida: A verificação de exaustividade transforma o compilador em um parceiro vigilante que garante que você tratou todas as variantes de dados possíveis.
- Refatoração sem Esforço: Adicionar novas variantes aos seus modelos de dados se torna um processo seguro e guiado. O compilador apontará cada local em sua base de código que precisa ser atualizado.
- Redução de Código Repetitivo: Bibliotecas como a
ts-patternfornecem uma sintaxe concisa, poderosa e elegante que é muitas vezes muito mais limpa do que as estruturas de controle de fluxo tradicionais.
Conclusão: Abrace a Confiança em Tempo de Compilação
Mudar de estruturas de controle de fluxo tradicionais e inseguras para o pattern matching type-safe é uma mudança de paradigma. Trata-se de mover as verificações do tempo de execução, onde se manifestam como bugs para seus usuários, para o tempo de compilação, onde aparecem como erros úteis para você, o desenvolvedor. Ao combinar as discriminated unions do TypeScript com o poder da verificação de exaustividade — seja através de uma asserção manual com never ou uma biblioteca como a ts-pattern — você pode construir aplicações mais robustas, fáceis de manter e resilientes a mudanças.
Na próxima vez que você se encontrar escrevendo uma longa cadeia if-else if-else ou uma instrução switch em uma propriedade de string, pare um momento para considerar se você pode modelar seus dados como uma discriminated union. Faça o investimento em segurança de tipos. Seu eu futuro, e sua base de usuários global, agradecerão pela estabilidade e confiabilidade que isso traz para o seu software.